Skip to content

Add support for automatic naming of random variables #6364

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

zaxtax
Copy link
Contributor

@zaxtax zaxtax commented Dec 3, 2022

This PR introduces a way to use distributions without needing to provide a name.

This means:

import pymc as pm
with pm.Model():
    x = pm.Normal(0., 1.)

is equivalent to

import pymc as pm
with pm.Model():
    x = pm.Normal("x", 0., 1.)

I use some code originally written by @Tobias-Kohn for pyprob. The implementation is done by inspecting stack frames to figure out what the assignment will be. It picks earliest point an assignment can take place.

This PR is intended to be fully backwards-compatible.

Checklist

@codecov
Copy link

codecov bot commented Dec 3, 2022

Codecov Report

Merging #6364 (97b1f58) into main (5688555) will decrease coverage by 0.02%.
Report is 434 commits behind head on main.
The diff coverage is 84.31%.

❗ Current head 97b1f58 differs from pull request most recent head e835fa0. Consider uploading reports for the commit e835fa0 to get more accurate results

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6364      +/-   ##
==========================================
- Coverage   94.72%   94.71%   -0.02%     
==========================================
  Files         132      132              
  Lines       26740    26789      +49     
==========================================
+ Hits        25330    25372      +42     
- Misses       1410     1417       +7     
Files Coverage Δ
pymc/tests/distributions/test_distribution.py 98.31% <100.00%> (+0.09%) ⬆️
pymc/distributions/distribution.py 93.18% <78.94%> (-2.00%) ⬇️

Copy link
Member

@ricardoV94 ricardoV94 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty wild 😜 This will require group approval for sure but could make it to PyMC5 if it goes through.

Does it handle assignment in list comprehension?

value = frame.f_locals[var_name]
else:
value = None
if type(value) is int:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this for container[i] = pm.Normal(0, 1)?

Missing a test if that's the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested assignment in a list comprehension. Can you include a short example?

Lots of the complexity in _extract_target_of_assignment is for supporting a wider variety of things you can assign to like container[i].

Copy link
Member

@ricardoV94 ricardoV94 Dec 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I was asking what this integer case was for.

For list comprehension I was thinking normals = [pm.Normal() for i in range(5)]

Not saying we need to support it, just asking if it would be possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integer case is for assigning to tuples I believe. The list comprehension case does not work, but we could probably modify _extract_target_of_assignment to detect that situation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should hold off on supporting it. Generating unique names for comprehensions that are predictable to the user looks tricky to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The integer case is for assigning to tuples I believe.

Can you add a test?

@ricardoV94
Copy link
Member

@pymc-devs/dev-core Weight in :)

@ricardoV94 ricardoV94 added major Include in major changes release notes section request discussion labels Dec 3, 2022
@twiecki
Copy link
Member

twiecki commented Dec 3, 2022

Amazing!

@michaelosthege
Copy link
Member

I remember that I didn't like this pattern before, because it messes up the typing of the arguments =/

I know that the typing of distribution arguments isn't great in the first place, but we're also talking about refactoring all these to functions.
For functions we could have clean signatures and that would be a great improvement of UX too.

How would the signature of pm.Normal look like if it was a function?

Can this be done with overloads?

@zaxtax
Copy link
Contributor Author

zaxtax commented Dec 3, 2022

I remember that I didn't like this pattern before, because it messes up the typing of the arguments =/

I know that the typing of distribution arguments isn't great in the first place, but we're also talking about refactoring all these to functions. For functions we could have clean signatures and that would be a great improvement of UX too.

How would the signature of pm.Normal look like if it was a function?

Can this be done with overloads?

As you mentioned this doesn't change the typing too much as is. If we were to change the distributions to functions, I think this could be handled with an overload. One with a explicit name argument, and one without.

Most of the hackiness of this comes from trying to maintain full backwards-compatibility. If we were ok with an API break, this just gets typed with name becoming an optional argument.

name = args[0]
args = args[1:]
else:
name = _extract_target_of_assignment(2)
Copy link
Member

@michaelosthege michaelosthege Dec 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For situations where automated name determination fails, this part should raise an informative error.
For example, one might simply forget to pass the name:

with pmodel:
    pm.Normal("Y", pm.Uniform(), observed=2)

There are exceptions a few lines downstream, but here we should do a try/except

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or even better: make _extract_target_of_assignment raise the informative error and unit test that directly.

Copy link
Contributor Author

@zaxtax zaxtax Dec 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. What do you mean by an informative error message? I'm not sure what more can I say beyond that I failed to infer the name, and in the _extract_target_of_assignment that no assignment instruction was found at that frame depth.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something about "inferring the name from the outer code"

if you have a handle on the line of code print that

definitely instructions what do do about it: please pass a name as the first argument

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pushed. Does that look ok?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the message looks good, but I'd recommend to raise it in the extraction function instead of returning None. It gives a cleaner signature (str or raise) and separation of responsibility..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the error message changes. Are we ok with an Assignment not found error that is raised, caught, and re-raised as a Name needs to be provided error message?

@fonnesbeck
Copy link
Member

Very cool. We have been wanting to infer names since the days of PyMC2.

Copy link
Member

@michaelosthege michaelosthege left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zaxtax something like this

or maybe keep the try except outside, but raise instead of returning none

Comment on lines +317 to +333
if "name" in kwargs:
name = kwargs.pop("name")
elif len(args) > 0 and isinstance(args[0], string_types):
name = args[0]
args = args[1:]
else:
name = _extract_target_of_assignment(2)
if name is None:
raise TypeError(
"Name could not be inferred for variable from surrounding "
"context. Pass a name explicitly as the first argument to "
"the Distribution."
)

if not isinstance(name, string_types):
raise TypeError(f"Name needs to be a string but got: {name}")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if "name" in kwargs:
name = kwargs.pop("name")
elif len(args) > 0 and isinstance(args[0], string_types):
name = args[0]
args = args[1:]
else:
name = _extract_target_of_assignment(2)
if name is None:
raise TypeError(
"Name could not be inferred for variable from surrounding "
"context. Pass a name explicitly as the first argument to "
"the Distribution."
)
if not isinstance(name, string_types):
raise TypeError(f"Name needs to be a string but got: {name}")
name = kwargs.pop("name", None)
if name is None:
if args and isinstance(args[0], string_types):
# Name was provided as the first argument
name = args[0]
args = args[1:]
else:
# Try to infer name from the outer context.
# This may raise an error, but then there's nothing else we can do.
name = _extract_target_of_assignment(2)
if not isinstance(name, string_types):
raise TypeError(f"Name needs to be a string but got: {name}")

Comment on lines +168 to +204
# Helper function from pyprob
def _extract_target_of_assignment(depth):
frame = sys._getframe(depth)
code = frame.f_code
next_instruction = code.co_code[frame.f_lasti + 2]
instruction_arg = code.co_code[frame.f_lasti + 3]
instruction_name = opcode.opname[next_instruction]
if instruction_name == "STORE_FAST":
return code.co_varnames[instruction_arg]
elif instruction_name in ["STORE_NAME", "STORE_GLOBAL"]:
return code.co_names[instruction_arg]
elif (
instruction_name in ["LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL"]
and opcode.opname[code.co_code[frame.f_lasti + 4]] in ["LOAD_CONST", "LOAD_FAST"]
and opcode.opname[code.co_code[frame.f_lasti + 6]] == "STORE_SUBSCR"
):
if instruction_name == "LOAD_FAST":
base_name = code.co_varnames[instruction_arg]
else:
base_name = code.co_names[instruction_arg]

second_instruction = opcode.opname[code.co_code[frame.f_lasti + 4]]
second_arg = code.co_code[frame.f_lasti + 5]
if second_instruction == "LOAD_CONST":
value = code.co_consts[second_arg]
elif second_instruction == "LOAD_FAST":
var_name = code.co_varnames[second_arg]
value = frame.f_locals[var_name]
else:
value = None
if value is not None:
index_name = repr(value)
return base_name + "[" + index_name + "]"
else:
return None
else:
return None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Helper function from pyprob
def _extract_target_of_assignment(depth):
frame = sys._getframe(depth)
code = frame.f_code
next_instruction = code.co_code[frame.f_lasti + 2]
instruction_arg = code.co_code[frame.f_lasti + 3]
instruction_name = opcode.opname[next_instruction]
if instruction_name == "STORE_FAST":
return code.co_varnames[instruction_arg]
elif instruction_name in ["STORE_NAME", "STORE_GLOBAL"]:
return code.co_names[instruction_arg]
elif (
instruction_name in ["LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL"]
and opcode.opname[code.co_code[frame.f_lasti + 4]] in ["LOAD_CONST", "LOAD_FAST"]
and opcode.opname[code.co_code[frame.f_lasti + 6]] == "STORE_SUBSCR"
):
if instruction_name == "LOAD_FAST":
base_name = code.co_varnames[instruction_arg]
else:
base_name = code.co_names[instruction_arg]
second_instruction = opcode.opname[code.co_code[frame.f_lasti + 4]]
second_arg = code.co_code[frame.f_lasti + 5]
if second_instruction == "LOAD_CONST":
value = code.co_consts[second_arg]
elif second_instruction == "LOAD_FAST":
var_name = code.co_varnames[second_arg]
value = frame.f_locals[var_name]
else:
value = None
if value is not None:
index_name = repr(value)
return base_name + "[" + index_name + "]"
else:
return None
else:
return None
def _extract_target_of_assignment(depth) -> str:
"""Helper function to infer RV names from outer code.
Adapted from pyprob.
"""
try:
frame = sys._getframe(depth)
code = frame.f_code
next_instruction = code.co_code[frame.f_lasti + 2]
instruction_arg = code.co_code[frame.f_lasti + 3]
instruction_name = opcode.opname[next_instruction]
if instruction_name == "STORE_FAST":
return code.co_varnames[instruction_arg]
elif instruction_name in ["STORE_NAME", "STORE_GLOBAL"]:
return code.co_names[instruction_arg]
elif (
instruction_name in ["LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL"]
and opcode.opname[code.co_code[frame.f_lasti + 4]] in ["LOAD_CONST", "LOAD_FAST"]
and opcode.opname[code.co_code[frame.f_lasti + 6]] == "STORE_SUBSCR"
):
if instruction_name == "LOAD_FAST":
base_name = code.co_varnames[instruction_arg]
else:
base_name = code.co_names[instruction_arg]
second_instruction = opcode.opname[code.co_code[frame.f_lasti + 4]]
second_arg = code.co_code[frame.f_lasti + 5]
if second_instruction == "LOAD_CONST":
value = code.co_consts[second_arg]
elif second_instruction == "LOAD_FAST":
var_name = code.co_varnames[second_arg]
value = frame.f_locals[var_name]
else:
value = None
if value is not None:
index_name = repr(value)
return base_name + "[" + index_name + "]"
else:
raise Exception()
else:
raise Exception()
except Exception as ex:
raise TypeError(
"Name could not be inferred for variable from surrounding "
"context. Pass a name explicitly as the first argument to "
"the Distribution."
) from ex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks that clarifies things!

@aseyboldt
Copy link
Member

This is a fun and interesting idea, and I really enjoy the inventiveness of the implementation!

However, I'm not sure I really would want this to be in pymc. The main assumption of this is that people want to use the same name for a variable binding as for the (global) name of the variable in the trace. I just went through a couple of my models, and at least for me that just isn't the case. Less than half of the variables had the same name as the binding, especially for larger models, where variable names tend to get longer because they have to stay globally unique, and part of the model creation is in separate functions with local namespaces.
I had a quick look at models on discourse, and there I think the percentage looks like more than half, but still very far from 100%. There are lots of models that use different names for the local bindings, and I think often for good reason.

Also from a more abstract perspective this seems a bit unintuitive: the global name of a variable and the name of the variable binding are quite different things, and if we did this I think pymc would be the only library I know that uses binding names internally. It also goes very much against general rules in python, where the code where a function is called doesn't matter inside the call. It also adds a lot of strangeness in the argument order of the RV constructors, where we need quite a bit of logic to figure out if there is a name or not.

This seems to me to trade the small inconvenience of repeating a name from time to time for unique strangeness that goes against how python usually works at its core. That doesn't sound like a good tradeoff to me...

If we want to improve variable naming, I think it would be more interesting to think about namespaces. I've been waiting for xarray to eventually support nesting, if that were to happen I think that would help greatly

@ricardoV94
Copy link
Member

ricardoV94 commented Dec 4, 2022

How does this fare with Distribution constructor helpers such as ZeroInflatedPoisson, OrderedLogistic, LKJCholeskyCov, which are not distributions themselves?

Users should expect the same behavior but I don't want to make these cumbersome to write for developers.

@ricardoV94
Copy link
Member

Less than half of the variables had the same name as the binding, especially for larger models, where variable names tend to get longer because they have to stay globally unique, and part of the model creation is in separate functions with local namespaces.

This won't prevent users from using names, so I don't see how this is an argument against. Of course users didn't bother to give unique assignment names in the past, it didn't matter. Obviously that changes if you are using assignment to provide the name. If you repeat it you'll get the usual repeated name error from the Model.

What do you mean with local namespaces?

@aseyboldt
Copy link
Member

aseyboldt commented Dec 4, 2022 via email

@ricardoV94
Copy link
Member

ricardoV94 commented Dec 4, 2022

What do you mean with local namespaces?

I mean things like

def make_spacial_compontent():
 mu = pm.Normal("spacial_mu") 
 sigma = pm.HalfNormal("spacial_sigma") 
 ... 

So the global variable names are more complicated, but because we only ever see part of that in the function we use other variable names there.

If you pass a name to the model you get it as the prefix of every variable, so you could use a nested model for that already with this PR

Edit: Still I don't see the issue as long as you can pass names explicitly.

@twiecki
Copy link
Member

twiecki commented Dec 4, 2022

@aseyboldt I see your points, but I don't think you are the target audience for this feature. For me, this is really focused at beginners, and to them it's often not clear why they have to repeat things. Further optimizing the simplicity and readability of the HelloWorld model is pretty important I think. Intermediate and advanced users will probably keep using names.

Copy link
Member

@ricardoV94 ricardoV94 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this fare with Distribution constructor helpers such as ZeroInflatedPoisson, OrderedLogistic, LKJCholeskyCov, which are not distributions themselves?

Users should expect the same behavior but I don't want to make these cumbersome to write for developers.

As expected... these type of classes would have to be tweaked in this PR.

import pymc as pm
with pm.Model() as m:
    x = pm.ZeroInflatedPoisson(psi=0.5, mu=9)

# TypeError: ZeroInflatedPoisson.__new__() missing 1 required positional argument: 'name'

@zaxtax
Copy link
Contributor Author

zaxtax commented Dec 4, 2022

This is a fun and interesting idea, and I really enjoy the inventiveness of the implementation!

However, I'm not sure I really would want this to be in pymc. The main assumption of this is that people want to use the same name for a variable binding as for the (global) name of the variable in the trace. I just went through a couple of my models, and at least for me that just isn't the case. Less than half of the variables had the same name as the binding, especially for larger models, where variable names tend to get longer because they have to stay globally unique, and part of the model creation is in separate functions with local namespaces. I had a quick look at models on discourse, and there I think the percentage looks like more than half, but still very far from 100%. There are lots of models that use different names for the local bindings, and I think often for good reason.

Also from a more abstract perspective this seems a bit unintuitive: the global name of a variable and the name of the variable binding are quite different things, and if we did this I think pymc would be the only library I know that uses binding names internally. It also goes very much against general rules in python, where the code where a function is called doesn't matter inside the call. It also adds a lot of strangeness in the argument order of the RV constructors, where we need quite a bit of logic to figure out if there is a name or not.

This seems to me to trade the small inconvenience of repeating a name from time to time for unique strangeness that goes against how python usually works at its core. That doesn't sound like a good tradeoff to me...

If we want to improve variable naming, I think it would be more interesting to think about namespaces. I've been waiting for xarray to eventually support nesting, if that were to happen I think that would help greatly

Thanks @aseyboldt for your thoughtful feedback. I think these are valid points and I'm not even sure beginners mind having to label their random variables.

As I mentioned to Michael, much of the hackiness of this originates from trying to maintain full backwards-compatibility. Right now we have lots of user code that treats the name as the first argument. The metaprogramming bits though while very odd are no more odd than what happens with Model where we enter a context and now magically an object is modified behind the scenes. A small method that uses the surrounding scope seems like a more isolated form of magic.

There are also larger design questions that maybe could get us to the same destination but using a more elegant route. I'm not sure we need names for the random variables. They seem to me to be mostly needed for visualisation and diagnostic plots.

@ricardoV94
Copy link
Member

ricardoV94 commented Dec 4, 2022

I'm not sure we need names for the random variables.

We need names to link variables to sampling results. Otherwise you don't know what is what in the results of prior predictive and sample. Posterior predictive, likewise, relies on names to know what's provided and what needs to be resampled.

Storing and loading results with unnamed TensorVariables as the keys would be a mess.

@zaxtax
Copy link
Contributor Author

zaxtax commented Dec 4, 2022

Storing and loading results with unnamed TensorVariables as the keys would be a mess.

I think there is a small mess that comes from needing to hold onto the TensorVariables to use as keys. The big mess comes when you need to give them names to be plotted.

This comes up in other PPLs like BeanMachine where random variables are decorated functions. The name became the function's name concatenated with its arguments. Which leads to plots that usually don't look the cleanest.

@drbenvincent
Copy link

drbenvincent commented Dec 4, 2022

Will this work for unicode variable names? People have a habit of using unicode mu, sigma, beta, etc. If yes, it's worth adding some tests for that.

PS. I think this is great. Think it will allow for much easier on-ramping of beginners.

@zaxtax
Copy link
Contributor Author

zaxtax commented Dec 4, 2022

Will this work for unicode variable names? People have a habit of using unicode mu, sigma, beta, etc. If yes, it's worth adding some tests for that.

PS. I think this is great. Think it will allow for much easier on-ramping of beginners.

Seems to work with unicode characters

@twiecki
Copy link
Member

twiecki commented Dec 5, 2022

As Python has full unicode support I would expect it to just work, I don't think we need a test for that.

@@ -164,6 +165,45 @@ def fn(*args, **kwargs):
return fn


# Helper function from pyprob
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL to original commit on pyprob: pyprob/pyprob@6aac747

@fonnesbeck
Copy link
Member

fonnesbeck commented Dec 5, 2022

Manual naming would have to be retained in order to support things like re-usable model components. For example,

    def component_submodel(name, ...):

        # Aging curve
        c_age = pm.HalfNormal(f"c_age_{name}", 10)
        offset_age = pm.HalfNormal(f"offset_age_{name}", 1)

        ...

where the model instantiates several component_submodels that each have their own instances of all of the random variables created inside the function. There does not appear to be an automatic way of naming these.

@aseyboldt
Copy link
Member

Well, it seems like I'm the only one to who this looks like too much magic, if that's the case then I guess it's fine. :-)
I'll just argue a little bit more, to at least make sure we don't miss anything...

I guess the reason why this feels like too much magic to me is that this API is not really part of the python language: If you only use API that is not CPython specific, you can not (as far as I know) implement this API at all. In order to implement it, we have to use internal data of CPython that isn't really meant to be used when programming in the language, and that other Python implementations (or future versions of CPython) might not even have. To quote from the CPython docs about some of those functions:

https://docs.python.org/3/library/sys.html#sys._getframe

CPython implementation detail: This function should be used for internal and specialized purposes only. It is not guaranteed to exist in all implementations of Python.

https://docs.python.org/3/reference/datamodel.html#the-standard-type-hierarchy (for the frame attributes)

Internal types A few types used internally by the interpreter are exposed to the user. Their definitions may change with future versions of the interpreter, but they are mentioned here for completeness.

https://docs.python.org/3/library/dis.html?highlight=dis#module-dis

CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter. No guarantees are made that bytecode will not be added, removed, or changed between versions of Python. Use of this module should not be considered to work across Python VMs or Python releases.

At least bytecode also has changed quite a bit over the years as far as I am aware.

This is in contrast to something like the model context, that's just a glorified global variable, and only uses quite simple features of the python language.

I think it is fair to say that an API that can not be implemented in the language is a whole different level of magic. :-)

Given that I don't think it is actually that helpful or at least important, (and @twiecki I think breaking rules of the language is actually worse for beginners than for people who have worked with that for a long time), it just doesn't feel right to me.
Answering the question "Why do I have to reapeat the name" seems quite easy to me: "You are not really repeating anything, the string argument is the name in the trace, the name of the assignment is the local binding of that variable. Those two things don't really have anything to do with each other, only that maybe in some cases you might want to choose the same name.")

This also raises some compatibility questions that I think we really should investigate at least a bit before we commit to anything like this: It is quite possible that this can't actually be implemented at all in other python implementations, including future CPython versions. I had a quick look and maybe pypy would work, but I'm not sure. We might also want to look at pyston, because I think there is some talk of using similar jit ideas in future CPython versions. I don't think it is particularly likely, but it really would be unfortunate if we go for an API like this, and a few CPython versions later this suddenly can't be done anymore, or it can only be done in a different subset of cases.

@twiecki
Copy link
Member

twiecki commented Dec 5, 2022

Well, damn, you raise some good points @aseyboldt, need to let that sink in a bit more. One nit: "I think it is fair to say that an API that can not be implemented in the language is a whole different level of magic. :-)" I wouldn't say it's the API per se, because it's still just a function call, but rather the code implementing the API that's not Python.

@michaelosthege
Copy link
Member

michaelosthege commented Dec 5, 2022

Adrian isn't the only one with concerns.

If we do this, would we update the documentation to use this feature? All of it? Where would we draw the line?

It's clearly violating the "explicit is better than implicit" rule, and because all of it is happening behind the scenes, it may create more confusion than benefits. After all, the usual API of passing names may be verbose, but it's definitely easy to understand. In particular for examples that opt for informative RV names and short variable names (s = pm.Normal("slope")).

In the end I think its about trading off slope in the very early vs. medium stage learning curve. (Seemingly easier Quickstart examples at the cost of confusion about moderately complex models.)

@twiecki
Copy link
Member

twiecki commented Dec 5, 2022 via email

@drbenvincent
Copy link

@aseyboldt makes some very good points. While I think having the option would be great, it sounds like it's not really an option. Maybe we can better sell why the current apparently verbose approach is beneficial.

@ricardoV94
Copy link
Member

ricardoV94 commented Dec 6, 2022

I don't think there is any benefit to the current approach, it's just the best we could do within Python's limitations (other than going the JAGS way, and defining models as large strings :D). You have to be verbose. Making that optional would be great.

I agree that this is very inelegant, because we're trying to break out of Python's language. Still I think it's fine to exploit features only available in the CPython implementation. It's not like we are testing or explicitly supporting any other Python implementations.

The strongest argument against this approach is that the byte-code can be changed at any time rendering this approach impossible / cumbersome in the future. It would suck to have to backtrack.

@fonnesbeck
Copy link
Member

fonnesbeck commented Dec 6, 2022

I don't have the usual concerns with "too much magic" or being implicit rather than explicit in this case, because ultimately we are talking about naming variables here. If it saves users time and/or makes the model code more concise, then there is not really any downside to me, as long as the approach is robust and allows for corner cases to be accommodated. Moreover, as long as we retain the option to manually override with the name argument, then it should be backward-compatible.

@zaxtax
Copy link
Contributor Author

zaxtax commented Dec 9, 2022

I don't think there is any benefit to the current approach, it's just the best we could do within Python's limitations (other than going the JAGS way, and defining models as large strings :D). You have to be verbose. Making that optional would be great.

I agree that this is very inelegant, because we're trying to break out of Python's language. Still I think it's fine to exploit features only available in the CPython implementation. It's not like we are testing or explicitly supporting any other Python implementations.

The strongest argument against this approach is that the byte-code can be changed at any time rendering this approach impossible / cumbersome in the future. It would suck to have to backtrack.

In terms of the bytecode, while it can and does change a little every version release, this code seems to work across many version releases even going as far back as python 2.x

@twiecki
Copy link
Member

twiecki commented Dec 9, 2022

In terms of the bytecode, while it can and does change a little every version release, this code seems to work across many version releases even going as far back as python 2.x

That makes me feel better.

@lucianopaz
Copy link
Member

Could you also add a test where the model has a name? To me, it looks like the variable autoname will still have the model name prepended to it, but I think that we should explicitly test that it does.

@ricardoV94
Copy link
Member

Closing this as the reception was pretty mixed. Thanks anyway @zaxtax!

@ricardoV94 ricardoV94 closed this Jun 16, 2023
@ricardoV94 ricardoV94 reopened this Jun 16, 2023
@ricardoV94
Copy link
Member

Reopened, the floor is yours again

@zaxtax zaxtax marked this pull request as draft June 16, 2023 09:29
@ferrine
Copy link
Member

ferrine commented Jun 22, 2023

I went through the thread and I also feel that this is too much magic for beginners. I agree that this magic helper reduces code for just a little bit, but it creates more trouble than good.

  • The support of magic naming is not uniform and we put a user into a situation where user has to use or not to use magic naming based on the usage context.
  • It creates troubles under refactoring
  • Forward compatibility is questionable and we are about to step onto the ground if python_version == "3.15":... which does not sound fun and creates serious issues if this risk comes real
  • s = pm.Normal("slope") is a realistic usecase as mentioned by @michaelosthege and a user has to think twice between long vs short naming since it is not only affects trace analysis but also very near context where this slope has to be used

Sudden refactorings and iterations over model create issues back and forth, you refactor little scope code and it affects late visualizations. The modelling is not 1-shot, a thoughtful user iterates over the model and the easier it is the better. The verbose approach saves from thinking twice about naming and also saves from opportunistic coding but unexpected errors

@zaxtax
Copy link
Contributor Author

zaxtax commented Jun 22, 2023

I went through the thread and I also feel that this is too much magic for beginners. I agree that this magic helper reduces code for just a little bit, but it creates more trouble than good.

* The support of magic naming is not uniform and we put a user into a situation where user has to use or not to use magic naming based on the usage context.

* It creates troubles under refactoring

* Forward compatibility is questionable and we are about to step onto the ground `if python_version == "3.15":...` which does not sound fun and creates serious issues if this risk comes real

* `s = pm.Normal("slope")` is a realistic usecase as mentioned by @michaelosthege and a user has to think twice between long vs short naming since it is not only affects trace analysis but also very near context where this slope has to be used

Sudden refactorings and iterations over model create issues back and forth, you refactor little scope code and it affects late visualizations. The modelling is not 1-shot, a thoughtful user iterates over the model and the easier it is the better. The verbose approach saves from thinking twice about naming and also saves from opportunistic coding but unexpected errors

I should stress that the magic used here is not unheard of for even fairly popular Python packages https://lukeplant.me.uk/blog/posts/pythons-disappointing-superpowers/

What's the risk with a future version of python?

I don't think this PR is ready to merge if it can't be used in a uniform way across the codebase.

How common is this short name vs long name pattern in code? It feels if anything this feature encourages users to us descriptive variable names.

In either case, I appreciate the concerns you have shared

@fonnesbeck
Copy link
Member

fonnesbeck commented Jun 22, 2023

I wonder if its any more magical that what we currently do with the abuse of the Python context manager for adding variables to the model? This seems to be along the same lines, in terms of non-explicit behavior, except that we are giving variables names at the same time that we are having a graph built for us. I'm not yet sold myself, but it has the potential to be a major convenience with relatively little risk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
major Include in major changes release notes section request discussion
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants